本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。
購書連結 https://www.tenlong.com.tw/products/9789864344130
讓我們再次重新認識 JavaScript!
在上一篇文章當中,我們介紹了函式的參數與 arguments
物件,那麼今天的分享中,我們繼續來看看 Function 在 JavaScript 的各種不同面貌。
你可能常聽到人家在講 Callback function,但你真的知道 Callback function 是什麼嗎? 其實 Callback function 跟一般的函式沒什麼不同,差別在於被呼叫執行的時機。
先前介紹事件的時候有說過,「JavaScript 是一個事件驅動 (Event-driven) 的程式語言」,而事件的概念就如同:
辦公室電話響了 (事件被觸發 Event fired) -> 接電話 (處理事件 Event Handler)
而寫成程式碼就類似:
// 註:這裡只是比喻,並沒有電話響這個事件 XD
Office.addEventListener( '電話響', function(){ /* 接電話 */ }, false);
可以看到,Office
透過 addEventListener
方法註冊了一個事件,當這個事件被觸發時,它會去執行我們所指定的第二個參數,也就是某個「函式」(接電話)。
換句話說,這個函式只會在滿足了某個條件才會被動地去執行,我們就可以說這是一個 Callback function。
經歷過各種「事件」的你,相信也發現了一件事,所謂的「Callback function」其實就是「把函式當作另一個函式的參數,透過另一個函式來呼叫它」。
什麼意思呢? 除了剛剛介紹的「事件」以外,還有另一個經典的案例:
window.setTimeout( function(){ ... }, 1000);
如果我們希望隔某段時間之後,執行某件事,就可以透過 window.setTimeout
來幫助我們達成。
像是上面的範例中, window.setTimeout
帶有兩個參數,第一個是要做的事情,用一個函式來代表,第二個則是時間 (1/1000 秒, milliseconds)。 而第一個參數的函式也是 Callback function 的一種:「在經過了某段時間之後,才執行的函式」。
當然,上面兩個範例中的 Callback function 我們也可以把它單獨抽出來定義:
var handler = function() {
/* 接電話 */
};
Office.addEventListener( '電話響', handler, false);
這樣會使你的程式看起來更清楚。
除了事件以外,還有另一個會需要用到 Callback function 的場景,就是「控制多個函式間執行的順序」。
什麼意思呢? 來看個簡單的例子。
這裡定義了兩個 function
:
var funcA = function(){
console.log('function A');
};
var funcB = function(){
console.log('function B');
};
funcA();
funcB();
因為 funcA
與 funcB
都會立即執行,所以執行結果必定為:
"function A"
"function B"
但是,假設我們改成這樣,加上一個隨機生成的等待時間:
var funcA = function(){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function A');
}, i * 1000);
};
var funcB = function(){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function B');
}, i * 1000);
};
funcA();
funcB();
這時候就沒辦法確定是 "function A"
會先出現還是 "function B"
會先出現了對吧?
像這種時候,為了確保執行的順序,就會透過 Callback function 的形式來處理:
// 為了確保先執行 funcA 再執行 funcB
// 我們在 funcA 加上 callback 參數
var funcA = function(callback){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function A');
// 如果 callback 是個函式就呼叫它
if( typeof callback === 'function' ){
callback();
}
}, i * 1000);
};
var funcB = function(){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function B');
}, i * 1000);
};
// 將 funcB 作為參數帶入 funcA()
funcA( funcB );
像這樣,無論 funcA
在執行的時候要等多久, funcB
都會等到 console.log('function A');
之後才執行。
不過需要注意的是,當函式之間的相依過深,callback 多層之後產生的「波動拳」維護起來就會很可怕!
getData(function (a) {
getMoreData(a, function (b) {
getMoreData(b, function (c) {
getMoreData(c, function (d) {
getMoreData(d, function (e) {
...
});
});
});
});
});
如果真的不幸需要寫到這麼多層,這點後續我們介紹到 Promise
時會再說明如何擺脫「波動拳」(a.k.a. "Callback Hell")。
在系列文的 重新認識 JavaScript: Day 10 函式 Functions 的基本概念 我們曾經介紹過,在 ES6 以前,JavaScript 變數有效範圍的最小單位是以 function
做分界的。
那麼,現在我就透過一個最經典的範例來為各位解說。
題目是這樣的:假設想透過迴圈 + setTimeout 來做到,在五秒鐘之內,每秒鐘依序透過 console.log
印出: 0 1 2 3 4
那麼你會怎麼做? 很多 JavaScript 的初學者可能會很直覺地寫下這樣的程式碼:
// 假設想透過迴圈 + setTimeout 來做到
// 每秒鐘將 i 的值 console 出來
for( var i = 0; i < 5; i++ ) {
window.setTimeout(function() {
console.log(i);
}, 1000);
}
真的是這樣嗎? 我們來看看執行的結果。
執行的結果是, console.log()
會在「一秒鐘之後」同時印出「五次 5
」。
5
5
5
5
5
為什麼會這樣呢,先前曾經說過:
「切分變數有效範圍的最小單位是 "function" 」
「切分變數有效範圍的最小單位是 "function" 」
「切分變數有效範圍的最小單位是 "function" 」
很重要,所以要再講三次。
假設我們的時間軸是這樣的:
但我們知道, JavaScript 是一個「非同步」的語言,所以當我們執行這段程式時, for
迴圈並不會等待 window.setTimeout
結束後才繼續,而是在執行階段就一口氣跑完。
for( var i = 0; i < 5; i++ ) {
window.setTimeout(function() {
console.log(i);
}, 1000);
}
很明顯地,這段程式有兩個問題需要被解決:
1. 提出問題的人 (不是)
console.log
印出來的數字console.log
執行的時間首先先看「印出來的數字」。
由於切分變數有效範圍的最小單位是 function
,這代表著當我每一次執行 setTimeout
的時候,
window.setTimeout(function() {
console.log(i);
}, 1000);
裡面 console.log(i);
的 i
變數是去函式「外層」拿的對吧?
(不知道為什麼的朋友趕快回去看 重新認識 JavaScript: Day 10 函式 Functions 的基本概念 )
也就是說,因為 for
迴圈並不會等待 window.setTimeout
結束後才繼續,所以當 window.setTimeout
內的 Callback Function 執行時,拿到的 i
已經是跑完 for()
迴圈的 5
。
那麼要怎麼解決這個問題呢?
利用 「切分變數有效範圍的最小單位是 "function" 」這個特性,我們可以把 window.setTimeout
透過一個「立即被呼叫的特殊函式」來包裝。
像這樣的寫法:
(function(){
// 做某事...
})();
很多從其他語言轉來寫 JavaScript 的朋友可能不習慣。
沒關係,我們來拆解一下結構,你就會發現這樣的寫法其實沒有那麼難懂。
首先從一般單純的函式寫起:
function doSomething ( i ){
// 做某事...
}
有個叫 doSomething
的函式,裡面有個參數 i
,當我們要呼叫的時候,會用小括號 ()
來呼叫它: doSomething( xxx )
,大家應該都很熟悉了。
那麼,假設我們希望在定義函式的當下就呼叫它:
()
把這個函式包起來:(function doSomething ( i ){
// 做某事...
})
()
(function doSomething ( i ){
// 做某事...
})(123)
看出來了嗎?
一般的呼叫方式:
doSomething(123);
函式宣告當下即呼叫:
(function doSomething ( i ){
// 做某事...
})(123);
假設這個函式我們只需要在宣告的當下執行,之後不會再呼叫,那麼甚至連名字都不用了:
(function ( i ){
// 做某事...
})(123);
喔喔喔! 這不就是我們一開始看到的那個「立即被呼叫的特殊函式」(Immediately Invoked Function Expression, IIFE) 嗎!
(function(){
// 做某事...
})();
回到 for
迴圈與 window.setTimeout
的問題。
for( var i = 0; i < 5; i++ ) {
window.setTimeout(function() {
console.log(i);
}, 1000);
}
我們知道像上面這樣的寫法,在執行 window.setTimeout
的時候, i
早已變成了 5
,那麼為了保留每一次執行迴圈時那個「當下的」 i
,我們可以用一個立即被呼叫的特殊函式將它包覆起來,然後將 i
作為參數傳入:
for( var i = 0; i < 5; i++ ) {
// 為了凸顯差異,我們將傳入後的參數改名為 x
// 當然由於 scope 的不同,要繼續在內部沿用 i 也是可以的。
(function(x){
window.setTimeout(function() {
console.log(x);
}, 1000);
})(i);
}
這時候你會發現,執行的結果就會是我們預期的 0 1 2 3 4
了。
但還是在一秒鐘後同時出現啊?
嘿嘿,相信聰明的你已經發現,由於 for
迴圈在一瞬間就跑完,等於那一瞬間它向 window
依序註冊了五次 timer,每個 timer 都只等待一秒鐘,當然同時出現囉。
所以我們稍微修改一下:
for( var i = 0; i < 5; i++ ) {
(function(x){
// 將原本的 1000 改成 1000 * x
window.setTimeout(function() {
console.log(x);
}, 1000 * x);
})(i);
}
像這樣,就可以依序印出我們要的結果囉!
上面我們用了一個經典的案例來跟各位介紹 IIFE 的用法,除了在迴圈內呼叫 function 我們會需要用 IIFE 來把參數的值保留起來之外 [註1],IIFE 還有另外幾個好處,就是可以減少「全域變數」的產生,同時也避免了變數名稱衝突的機會。
如果你有去看過 jQuery 的原始碼,就會發現 jQuery 也用了相同的手法將 window
與 undefined
[註2] 保留起來:
(function( window, undefined ) {
// 略...
})( window );
[註1] ES6 以後新增了 let
與 const
,且改以 { }
作為它的 Scope。
換句話說,將範例中的 for
改為 let
就可以做到保留 i
在執行迴圈當下的「值」的效果:
for( let i = 0; i < 5; i++ ) {
window.setTimeout(function() {
console.log(i);
}, 1000);
}
[註2] undefined
是可以被修改的 (詳見 重新認識 JavaScript: Day 03 變數與資料型別 ) ,所以 jQuery 雖然在 IIFE 定義了兩個參數,但只傳了一個 winodw
,就是為了保持 undefined
原本的樣子。
感謝大佬,終於比較全面性的了解CallBack了
我對於IIFE的理解是可以防止變量及外部訪問
想問一下 let 解決了 var 汙染全域問題,是否也同時有 IIFE寫法帶來的優點
IIFE 還有需要被使用嗎?(在可以用ES6的環境下)
有需要能用在哪裡呢?
過去使用 IIFE 最主要的原因就是為了避免變數污染造成的問題。
若是 ES6+ 環境以 let
取代 var
,再加上 { ... }
將變數鎖定在 block-scope 之後,就可以完全取代 IIFE。
了解了 感謝!
請問為什麼 印出aaa
是迴圈跑完的i
because object is "call by reference".
// 為了確保先執行 funcA 再執行 funcB
// 我們在 funcA 加上 callback 參數
var funcA = function(callback){
var i = Math.random() + 1;
window.setTimeout(function(){
console.log('function A');
// 如果 callback 是個函式就呼叫它
if( typeof callback === 'function' ){
callback();
}
}, i * 1000);
};
您好,想請問一下上列的程式碼中,if( typeof callback === 'function' ){callback();}這段如果單純只寫成callback();不加判別的話,是否還能確保先執行 funcA 再執行 funcB呢?
你好,
if( typeof callback === 'function' ){ }
加上判斷是只是為了確保傳入的 callback
參數可以被呼叫,與執行順序無關喔。
換句話說,將範例中的 for 改為 let 就可以做到保留 i 在執行迴圈當下的「值」的效果:
您好,這邊我有點不太清楚,是不是把 var 改為 let 的意思?
是的